Chapter 6

Asynchronous Programming: Futures, Streams, and Isolates

Session 6

Learning Objectives

By the end of this chapter, you will be able to:

1

Why Asynchronous Programming?

The Problem

Mobile apps need to perform operations that take time (network requests, file I/O, database queries). If these operations block the UI thread, the app becomes unresponsive and freezes.

The Solution

Asynchronous programming allows time-consuming operations to run in the background while the UI remains responsive. Flutter uses Dart's async/await syntax and Future/Stream APIs for this.

Key principle: Never block the UI thread. All I/O operations should be asynchronous.

2

Futures and async/await

What is a Future?

A Future represents a value that will be available at some point in the future. It's used for single asynchronous operations that return one value.

Basic Future Example

Future fetchData() async {
  // Simulate network delay
  await Future.delayed(Duration(seconds: 2));
  return 'Data loaded';
}

// Usage with async/await
void loadData() async {
  final data = await fetchData();
  print(data);  // Prints: Data loaded
}

async/await Syntax

  • Mark functions that use await with async
  • await pauses execution until the Future completes
  • Async functions return a Future automatically
  • Use await to get the actual value from a Future

Multiple Async Operations

// Sequential (one after another)
Future loadSequential() async {
  final user = await fetchUser();
  final posts = await fetchPosts(user.id);
  print('Loaded ${posts.length} posts');
}

// Parallel (at the same time)
Future loadParallel() async {
  final results = await Future.wait([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ]);
  // All complete at the same time
}
3

Error Handling in Async Code

try-catch with async/await

Use try-catch blocks to handle errors in async functions:

Error Handling Example

Future loadDataSafely() async {
  try {
    final data = await fetchData();
    print('Success: $data');
  } on TimeoutException {
    print('Request timed out');
  } on HttpException catch (e) {
    print('HTTP error: $e');
  } catch (e) {
    print('Unexpected error: $e');
  }
}

Future Error Handling

You can also use then and catchError with Futures:

then/catchError Pattern

fetchData()
  .then((data) {
    print('Success: $data');
  })
  .catchError((error) {
    print('Error: $error');
  });
4

Streams for Continuous Data

What is a Stream?

A Stream provides a sequence of asynchronous events over time. Use streams for continuous data flows like user input, network responses, or file reading.

Basic Stream Example

Stream countStream() async* {
  for (int i = 1; i <= 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    yield i;  // Emit value
  }
}

// Listening to a stream
void listenToStream() {
  countStream().listen(
    (value) => print('Received: $value'),
    onError: (error) => print('Error: $error'),
    onDone: () => print('Stream completed'),
  );
}

StreamBuilder Widget

Flutter's StreamBuilder widget rebuilds UI when stream emits new data:

StreamBuilder Example

StreamBuilder(
  stream: countStream(),
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    }
    return Text('Count: ${snapshot.data}');
  },
)
5

Common Stream Operations

Stream Transformations

  • map() — Transform each event
  • where() — Filter events
  • take() — Take first N events
  • skip() — Skip first N events
  • distinct() — Remove duplicates
  • debounceTime() — Wait for pause in events

Stream Transformations Example

// Transform stream
final numbers = Stream.fromIterable([1, 2, 3, 4, 5]);
final doubled = numbers.map((n) => n * 2);
final evens = numbers.where((n) => n % 2 == 0);

// Combine streams
final stream1 = Stream.value(1);
final stream2 = Stream.value(2);
final combined = StreamZip([stream1, stream2]);
6

Isolates for Heavy Computation

What are Isolates?

Isolates are separate execution threads that run in parallel. They don't share memory, so they're perfect for CPU-intensive tasks that would block the UI thread.

When to Use Isolates

  • Heavy computations (image processing, large data parsing)
  • Complex calculations that take significant time
  • JSON parsing of very large files
  • Any CPU-bound work that could freeze the UI

Using compute() Function

// Heavy computation function (must be top-level or static)
int calculateSum(List numbers) {
  int sum = 0;
  for (int num in numbers) {
    sum += num * num;  // Simulate heavy work
  }
  return sum;
}

// Use compute() to run in isolate
Future processData() async {
  final numbers = List.generate(1000000, (i) => i);
  final result = await compute(calculateSum, numbers);
  print('Sum: $result');  // UI remains responsive
}

compute() Requirements

  • The function must be top-level or static
  • Parameters must be serializable (primitive types, lists, maps)
  • Return value must be serializable
  • No access to closures or instance variables
7

FutureBuilder and Async Operations in UI

FutureBuilder Widget

Use FutureBuilder to build UI based on Future completion:

FutureBuilder Example

FutureBuilder>(
  future: fetchUsers(),
  builder: (context, snapshot) {
    // Check connection state
    if (snapshot.connectionState == ConnectionState.waiting) {
      return CircularProgressIndicator();
    }
    
    // Check for errors
    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }
    
    // Check if data exists
    if (!snapshot.hasData) {
      return Text('No data');
    }
    
    // Build UI with data
    final users = snapshot.data!;
    return ListView.builder(
      itemCount: users.length,
      itemBuilder: (context, index) {
        return ListTile(title: Text(users[index].name));
      },
    );
  },
)

ConnectionState Values

  • ConnectionState.none — No connection yet
  • ConnectionState.waiting — Waiting for data
  • ConnectionState.active — Stream is active (for StreamBuilder)
  • ConnectionState.done — Operation completed
8

Best Practices

Async Programming Best Practices

  • Always handle errors in async operations
  • Use async/await instead of then/catchError for readability
  • Don't forget to check mounted before setState in async callbacks
  • Use Future.timeout() to prevent indefinite waiting
  • Cancel streams and futures when widgets are disposed
  • Use compute() for CPU-intensive tasks
  • Avoid creating unnecessary isolates for simple operations
  • Prefer StreamBuilder and FutureBuilder for reactive UI updates
9

Exercises

1. Async Data Loading

Create a screen that fetches user data from an API (use a mock function). Use FutureBuilder to display loading, error, and success states. Add a retry button for error cases.

2. Stream-based Timer

Build a countdown timer using a Stream that emits values every second. Use StreamBuilder to display the remaining time. Add start, pause, and reset functionality.

3. Heavy Computation with Isolates

Create a function that calculates the sum of squares for a large list (1 million numbers). Use compute() to run it in an isolate. Show a loading indicator while computing and display the result when done. Compare the UI responsiveness with and without isolates.